Skip to content

feat(checkout): plumb off-session card saving for yearly renewals (draft — needs consent UI)#265

Closed
kilbot wants to merge 3 commits into
mainfrom
feat/stripe-save-card
Closed

feat(checkout): plumb off-session card saving for yearly renewals (draft — needs consent UI)#265
kilbot wants to merge 3 commits into
mainfrom
feat/stripe-save-card

Conversation

@kilbot

@kilbot kilbot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Draft. Depends on #264merge #264 first. Until then this PR's diff also shows #264's commit; it narrows to just the Phase 2 delta once #264 lands. Do not mark ready until the owner decisions below are made.

What this is

The engineering seam for Level 2 (save the card), building on #264 (attach a Stripe Customer). It lets a yearly buyer's card be saved as a reusable, off-session Stripe payment method — the prerequisite for a future "click to renew" charge.

How it works

  • createPaymentSession gains an optional setupFutureUsage, forwarded as the session data.
  • Medusa's store payment-sessions route passes data verbatim into the Stripe provider's initiatePayment, where normalizePaymentIntentParameters copies setup_future_usage onto the PaymentIntent (verified in @medusajs/payment-stripe source).
  • The route sets 'off_session' only when all three hold: the buyer consented (saveCard), the plan is yearly (lifetime never renews), and it's the Stripe card provider (PayPal/BTCPay have no off-session-card concept).

⚠️ Why this is a draft — DORMANT until you decide

No client sets saveCard yet, so on the wire this changes nothing today. I deliberately stopped at the engineering boundary and did not build the consent UI, because it's a legal/UX decision that's yours:

  1. Consent copy + mandate text — the exact wording next to the "save my card" checkbox (card-network rules require a clear mandate for off-session reuse).
  2. Default state — checkbox checked or unchecked by default.
  3. Sequencing — a saved card is only useful once the renewal-charge flow (Level 3) exists (off-session charge → extend Keygen license, with an SCA fallback for EU/PL buyers). Storing cards before that flow exists means holding PII you can't yet use — decide whether to merge this ahead of L3 or hold it.

To activate: add the consent checkbox, thread its value as saveCard through the complete-cart client wrapper → this route. The seam is ready and tested.

Validation

  • vitest run1161/1161 pass (added: setup_future_usage threading; yearly-only / Stripe-only / consent-absent / lifetime gating)
  • eslint, tsc --noEmit, next build — all clean

Not in scope

Level 3 renewal-charge flow; the consent UI (see above); the Q2 order↔customer-linkage question flagged in #264.

🤖 Generated with Claude Code

Forward the signed-in customer's Medusa JWT on the payment-collection and
payment-session calls so Medusa resolves `auth_context.actor_id` to the
customer and creates/links a Stripe account holder (cus_...) on the
PaymentIntent — instead of the throwaway "Guest" that Stripe invents for
publishable-key-only requests.

The store payment-sessions workflow only creates an account holder
`when("customer-id-exists")`, and `customer_id` is derived solely from the
request's auth context; today the storefront's medusaFetch sends only the
publishable key, so actor_id is empty and every sale shows as Guest.

getCustomer() in the route already validates the token against
/store/customers/me (which rejects an empty actor_id), so the forwarded
token is guaranteed to resolve to a real customer. A missing token degrades
to the previous guest behaviour rather than breaking checkout.

Scope: attach-customer only (bookkeeping/reconciliation). Saving the card
for off-session renewals is a separate follow-up.
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 18 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d3bf5980-59a3-4183-9375-d8a033178631

📥 Commits

Reviewing files that changed from the base of the PR and between 2ef1925 and 9ac68ca.

📒 Files selected for processing (4)
  • src/app/api/store/cart/payment-sessions/route.test.ts
  • src/app/api/store/cart/payment-sessions/route.ts
  • src/services/core/external/medusa-client.test.ts
  • src/services/core/external/medusa-client.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stripe-save-card

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

🚀 Preview: https://wcpos-m8t8i1ffr-wcpos.vercel.app

kilbot added 2 commits July 6, 2026 11:39
De-duplicate the authToken -> Authorization header spread shared by
createPaymentCollection and createPaymentSession into one buildAuthHeaders()
helper. Behaviour-preserving (returns the same Bearer header, or {} when no
token); addresses a review nitpick.
Add the engineering seam that saves a buyer's card as a reusable,
off-session Stripe payment method — the prerequisite for a future
"click to renew" charge on the yearly plan.

createPaymentSession now accepts an optional setup_future_usage, forwarded
as the session `data`; Medusa's store payment-sessions route passes `data`
into the Stripe provider's initiatePayment, where it lands on the
PaymentIntent (verified against @medusajs/payment-stripe
normalizePaymentIntentParameters). The route sets 'off_session' only when
the buyer consented (saveCard), the plan is yearly (lifetime never renews),
and it's the Stripe card provider (PayPal/BTCPay have no off-session-card
concept). A saved card is only chargeable because the customer is attached
(previous commit).

DORMANT until a consent UI is added: no client sets saveCard yet, so today
this changes nothing on the wire. The consent checkbox + mandate copy is a
deliberate owner/legal decision and is intentionally NOT included here.
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🚀 Preview: https://wcpos-jnl0aw2b3-wcpos.vercel.app

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kilbot has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@kilbot

kilbot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Closing — superseded by the CustomerSession approach. This PR forced setup_future_usage: off_session server-side for yearly carts, which would save every card with no opt-out. Per the owner's choice of Stripe's built-in optional checkbox, the correct mechanism is a Stripe CustomerSession (wcpos/wcpos-medusa#65) whose customer_session_client_secret the storefront passes to <Elements>, so Stripe renders an optional save checkbox and only saves on explicit consent. #264 (attach the Stripe Customer), which this was stacked on, is already merged and remains the shared foundation. None of this PR's setup_future_usage/saveCard seam carries forward.

@kilbot kilbot closed this Jul 6, 2026
kilbot added a commit that referenced this pull request Jul 6, 2026
#283)

Wire the storefront to Stripe's optional "save my card" checkbox for yearly
card checkouts. For a yearly Stripe payment session the API route now also mints
a Stripe CustomerSession (new medusa endpoint POST /store/carts/:id/customer-session,
wcpos-medusa#65) and returns its `customer_session_client_secret`; the value is
threaded response → checkout-client → PaymentStep → StripeProvider and passed to
<Elements customerSessionClientSecret>. Stripe then renders its own checkbox with
a compliant off-session mandate and only saves the card on explicit consent — we
never force-save.

- Yearly + Stripe only. Non-yearly / non-card / no Stripe customer / any error →
  null secret → no checkbox; checkout is never blocked.
- Degrades gracefully before the medusa endpoint is deployed (404 → null).
- Replaces the closed #265 (which forced setup_future_usage with no opt-out).

Tests: route mints/gates the session (yearly / lifetime / non-Stripe); the
medusa client returns/nulls the secret; full suite + build green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant